iT邦幫忙

2024 iThome 鐵人賽

DAY 15
1
佛心分享-SideProject30

收納規劃APP系列 第 15

Day15:終於把家具拆出來

  • 分享至 

  • xImage
  •  

本日進度 Day15

鐵人賽30天感覺我大概拆了15天,使用Angular的裝飾器來直接操作 DOM 元素
因為SVG的 裡面不能放 ng container,也不能放自訂義的元件,所以跟裝飾器搏鬥了一下
先來看一下成果,真的是乾淨優雅多了

<app-back2-home></app-back2-home>

<div style="width: 100%; height: 80vh;">
    <svg #floorPlan width="100%" height="100%" viewBox="0 0 500 400" xmlns="http://www.w3.org/2000/svg">
        <defs>
            <g id="room-outline">
                <rect x="50" y="50" width="400" height="300" fill="none" stroke="black" stroke-width="2" />
                <path d="M50 300 A 50 50 0 0 1 100 350" fill="none" stroke="black" stroke-width="2" />
                <line x1="250" y1="50" x2="350" y2="50" stroke="black" stroke-width="2" />
            </g>
        </defs>
        <use href="#room-outline" />
        <g *ngFor="let furniture of furnitureList" [appFurniture]="furniture" 
            (dblclick)="handleDoubleClick(furniture,$event)" (updatePosition)="updateFurniturePosition($event)"
            (updateRotation)="updateFurnitureRotation($event)" (updateSize)="updateFurnitureSize($event)">
        </g>
    </svg>
</div>

<app-popover [isVisible]="isPopoverVisible" [x]="popoverX" [y]="popoverY" (close)="onPopoverClose()"
    (buttonClick)="onPopoverButtonClick($event)">
</app-popover>
import { Component, ElementRef, ViewChild } from '@angular/core';
import { NzModalService } from 'ng-zorro-antd/modal';
import { EditType } from 'src/app/feature/enum/furniture.enum';
import { Furniture } from 'src/app/feature/interface/furniture.interface';
import { FurnitureModalComponent } from 'src/app/feature/modal/furniture-modal/furniture-modal.component';

@Component({
  selector: 'app-day15',
  templateUrl: './day15.component.html',
  styleUrls: ['./day15.component.scss']
})
export class Day15Component {

  @ViewChild('floorPlan') floorPlanRef!: ElementRef<SVGSVGElement>;
  @ViewChild('popover') popover!: ElementRef;
  editType: EditType = EditType.furniture;
  EditType = EditType;
  isPopoverVisible = false;
  furnitureSetting!: Furniture;
  popoverX = 0;
  popoverY = 0;

  furnitureList: Furniture[] = [
    {
      id: '0', type: '沙發', x: 100, y: 100, width: 120, height: 60, color: 'lightblue', rotation: 0
    },
    {
      id: '1', type: '桌子', x: 300, y: 200, width: 80, height: 80, color: 'brown', rotation: 0
    },
    {
      id: '2', type: '床', x: 300, y: 70, width: 150, height: 80, color: 'beige', rotation: 0
    },
    {
      id: '3', type: '書櫃', x: 51, y: 51, width: 40, height: 100, color: 'burlywood', rotation: 0
    }
    ,
    {
      id: '4', type: '椅子', x: 250, y: 225, width: 40, height: 40, color: 'green', rotation: 0
    }
  ];

  constructor(private modal: NzModalService) { }

  handleDoubleClick(item: Furniture, event: MouseEvent): void {
    this.isPopoverVisible = true;
    this.popoverX = event.clientX;
    this.popoverY = event.clientY;
    this.furnitureSetting = item;
  }

  onPopoverClose() {
    this.isPopoverVisible = false;
  }

  onPopoverButtonClick(editType: EditType) {
    switch (editType) {
      case EditType.furniture:
        this.isPopoverVisible = false;
        this.showPopup(this.furnitureSetting);

        break;

      default:
        break;
    }
  }

  updateFurniturePosition($event: Furniture) {

  }
  updateFurnitureRotation($event: Furniture) {

  }
  updateFurnitureSize($event: Furniture) {

  }
  private showPopup(item: Furniture): void {
    console.log('showPopup', item);
    this.modal.create({
      // nzTitle: tplTitle,
      nzContent: FurnitureModalComponent,
      nzFooter: null,
      nzMaskClosable: false,
      nzClosable: false,
      // nzComponentParams: {
      //   // value: 'Template Context'
      // },
      nzOnOk: () => console.log('Click ok')
    });
  }

}

裝飾器通過 ElementRef 獲得對它所附加元素的直接引用,使得裝飾器可以直接訪問和操作這個元素。

之後透過Renderer2 服務來安全地操作 DOM。

昨天拆不出來是因為 offset 設定的關係,後來多加一個 initialFurniturePosition 來處理,還有在onChange 的時候 updateFurnitureTransform(),原理還沒搞得很明白,總之是會動了
感覺還可以優化的方向有Renderer2@HostListener

import { AfterViewInit, Directive, ElementRef, EventEmitter, Input, OnChanges, OnInit, Output, Renderer2, SimpleChanges } from '@angular/core';
import { Furniture } from '../interface/furniture.interface';
import { fromEvent, Subject, takeUntil } from 'rxjs';

@Directive({
  selector: '[appFurniture]'
})
export class FurnitureDirective implements OnInit, OnChanges, AfterViewInit {
  @Input('appFurniture') furniture!: Furniture;
  @Output() updatePosition = new EventEmitter<Furniture>();
  @Output() updateRotation = new EventEmitter<Furniture>();
  @Output() updateSize = new EventEmitter<Furniture>();
  private interactionState: 'idle' | 'dragging' | 'resizing' | 'rotating' = 'idle';
  private destroy$ = new Subject<void>();
  private offset = { x: 0, y: 0 };
  private initialFurniturePosition = { x: 0, y: 0 };

  constructor(
    private el: ElementRef,
    private renderer: Renderer2) { }

  ngOnInit() {
    this.createFurniture();
  }

  ngAfterViewInit() {
    this.setupEventListeners();

  }

  ngOnChanges(changes: SimpleChanges) {
    if (changes['furniture'] && !changes['furniture'].firstChange) {
      this.updateFurnitureTransform();
    }
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }

  private createFurniture() {
    const fragment = document.createDocumentFragment();
    const svgNS = "http://www.w3.org/2000/svg";

    const g = document.createElementNS(svgNS, 'g');
    g.classList.add('furniture');
    g.setAttribute('id', this.furniture.id);

    const rect = this.createSVGElement('rect', {
      width: this.furniture.width.toString(),
      height: this.furniture.height.toString(),
      fill: this.furniture.color,
      stroke: 'black'
    });

    const text = this.createSVGElement('text', {
      x: (this.furniture.width / 2).toString(),
      y: (this.furniture.height / 2).toString(),
      'text-anchor': 'middle',
      'dominant-baseline': 'middle',
      fill: 'black'
    });
    text.textContent = this.furniture.type;

    const resizeHandle = this.createSVGElement('circle', {
      cx: this.furniture.width.toString(),
      cy: this.furniture.height.toString(),
      r: '5',
      class: 'resize-handle'
    });

    const rotateHandle = this.createSVGElement('circle', {
      cx: (this.furniture.width / 2).toString(),
      cy: '-20',
      r: '5',
      fill: 'red',
      class: 'rotate-handle'
    });

    g.append(rect, text, resizeHandle, rotateHandle);

    this.updateFurnitureTransform();
    fragment.appendChild(g);

    this.renderer.appendChild(this.el.nativeElement, fragment);
  }

  private createSVGElement(type: string, attributes: { [key: string]: string }): SVGElement {
    const element = document.createElementNS("http://www.w3.org/2000/svg", type);
    Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, value));
    return element;
  }

  private updateFurnitureTransform() {
    const element = this.el.nativeElement as SVGGElement;
    const centerX = this.furniture.width / 2;
    const centerY = this.furniture.height / 2;
    element.style.transform = `translate(${this.furniture.x}px,${this.furniture.y}px) rotate(${this.furniture.rotation}deg)`;
    element.style.transformOrigin = `${centerX}px ${centerY}px`;
  }

  private updateFurnitureElement() {
    const element = this.el.nativeElement as SVGGElement;

    const rect = element.querySelector('rect') as SVGRectElement;
    const text = element.querySelector('text') as SVGTextElement;
    const resizeHandle = element.querySelector('.resize-handle') as SVGCircleElement;
    const rotateHandle = element.querySelector('.rotate-handle') as SVGCircleElement;

    rect.setAttribute('width', this.furniture.width.toString());
    rect.setAttribute('height', this.furniture.height.toString());

    text.setAttribute('x', (this.furniture.width / 2).toString());
    text.setAttribute('y', (this.furniture.height / 2).toString());

    resizeHandle.setAttribute('cx', this.furniture.width.toString());
    resizeHandle.setAttribute('cy', this.furniture.height.toString());

    rotateHandle.setAttribute('cx', (this.furniture.width / 2).toString());

    this.updateFurnitureTransform();
  }

  private setupEventListeners() {
    fromEvent<PointerEvent>(this.el.nativeElement, 'pointerdown')
      .pipe(takeUntil(this.destroy$))
      .subscribe(this.handlePointerDown.bind(this));

    fromEvent<PointerEvent>(document, 'pointermove')
      .pipe(takeUntil(this.destroy$))
      .subscribe(this.handlePointerMove.bind(this));

    fromEvent<PointerEvent>(document, 'pointerup')
      .pipe(takeUntil(this.destroy$))
      .subscribe(this.handlePointerUp.bind(this));
  }

  private handlePointerDown(event: PointerEvent) {
    const target = event.target as SVGElement;
    if (target.classList.contains('resize-handle')) {
      this.startResize(event);
      } else if (target.classList.contains('rotate-handle')) {
        this.startRotating(event);
    } else {
      this.startDrag(event);
    }
  }

  private handlePointerMove(event: PointerEvent) {
    if (this.interactionState !== 'idle') {
      event.preventDefault();
      const svgPoint = this.getSVGPoint(event);
      requestAnimationFrame(() => {
        switch (this.interactionState) {
          case 'dragging':
            this.drag(svgPoint);
            break;
          case 'resizing':
            this.resize(event);
            break;
          case 'rotating':
            this.rotate(event);
            break;
        }
      });
    }
  }

  private handlePointerUp() {
    this.interactionState = 'idle';
  }

  private startDrag(event: PointerEvent) {
    this.interactionState = 'dragging';
    const svgPoint = this.getSVGPoint(event);
    this.initialFurniturePosition = { x: this.furniture.x, y: this.furniture.y };
    this.offset = {
      x: svgPoint.x - this.initialFurniturePosition.x,
      y: svgPoint.y - this.initialFurniturePosition.y
    };
  }

  private drag(point: DOMPoint) {
    if (this.furniture) {
      console.log(point);
      this.furniture.x = point.x - this.offset.x;
      this.furniture.y = point.y - this.offset.y;
      this.updateFurnitureTransform();
      this.updatePosition.emit(this.furniture)
    }
  }

  private startResize(event: PointerEvent) {
    event.stopPropagation();
    this.interactionState = 'resizing';

    if (this.furniture) {
      const svgPoint = this.getSVGPoint(event);
      this.offset = {
        x: svgPoint.x - this.furniture.x - this.furniture.width,
        y: svgPoint.y - this.furniture.y - this.furniture.height
      };
    }
  }

  private resize(event: PointerEvent) {
    const svgPoint = this.getSVGPoint(event);

    const newWidth = Math.max(20, svgPoint.x - this.furniture.x);
    const newHeight = Math.max(20, svgPoint.y - this.furniture.y);

    this.furniture.width = newWidth;
    this.furniture.height = newHeight;

    this.updateFurnitureElement();
  }

  private startRotating(event: PointerEvent) {
    this.interactionState = 'rotating';

    if (this.furniture) {
      const angle = this.getAngle(event, this.furniture);
      this.offset.x = angle - this.furniture.rotation;
    }
  }

  private rotate(event: PointerEvent) {
    if (this.el.nativeElement && this.furniture) {
      const angle = this.getAngle(event, this.furniture);
      this.furniture.rotation = angle - this.offset.x;
      this.updateFurnitureTransform();
    }
  }

  private getAngle(event: PointerEvent, furniture: Furniture): number {
    const element = this.el.nativeElement as SVGGElement;
    const rect = element!.getBoundingClientRect();
    const centerX = rect.left + rect.width / 2;
    const centerY = rect.top + rect.height / 2;
    return Math.atan2(event.clientY - centerY, event.clientX - centerX) * (180 / Math.PI);
  }

  private getSVGPoint(event: PointerEvent): DOMPoint {
    const svg = this.el.nativeElement.closest('svg') as SVGSVGElement;
    const pt = svg.createSVGPoint();
    pt.x = event.clientX;
    pt.y = event.clientY;
    return pt.matrixTransform(svg.getScreenCTM()!.inverse());
  }
}


上一篇
Day14:拆出popover的元件
下一篇
Day16:點擊家具出現收納清單
系列文
收納規劃APP32
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言